/*
 * Copyright (c) 2011 Obix Labs Limited
 * Redistribution and use in source and binary forms, 
 * with or without modification, are permitted provided 
 * that the following conditions are met:
 * 
 * 		Redistribution of source code must retain the above 
 * 		copyright notice, this list of conditions and the 
 * 		following disclaimer.
 *
 * 		Redistribution in binary form must reproduce the 
 * 		above copyright notice, this list of conditions 
 * 		and the following disclaimer in the documentation 
 * 		and/or other materials provided with the distribution.
 * 
 * 	THIS SOFTWARE IS PROVIDED "AS IS," WITHOUT A WARRANTY OF 
 * 	ANY KIND. ALL EXPRESS OR IMPLIED CONDITIONS, REPRESENTATIONS 
 * 	AND WARRANTIES, INCLUDING ANY IMPLIED WARRANTY OF MERCHANTABILITY, 
 * 	FITNESS FOR A PARTICULAR PURPOSE OR NON-INFRINGEMENT, 
 * 	ARE HEREBY EXCLUDED. OBIX LABS LIMITED ("Obix Labs") AND ITS 
 * 	LICENSORS SHALL NOT BE LIABLE FOR ANY DAMAGES SUFFERED BY LICENSEE 
 * 	AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THIS SOFTWARE OR 
 * 	ITS DERIVATIVES. IN NO EVENT WILL Obix Labs OR ITS LICENSORS BE 
 * 	LIABLE FOR ANY LOST REVENUE, PROFIT OR DATA, OR FOR DIRECT, 
 * 	INDIRECT, SPECIAL, CONSEQUENTIAL, INCIDENTAL OR PUNITIVE 
 * 	DAMAGES, HOWEVER CAUSED AND REGARDLESS OF THE THEORY OF 
 * 	LIABILITY, ARISING OUT OF THE USE OF OR INABILITY TO USE THIS 
 * 	SOFTWARE, EVEN IF Obix Labs HAS BEEN ADVISED OF THE POSSIBILITY OF 
 * 	SUCH DAMAGES.
 */
package com.obixlabs.commons.zip;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.util.Deque;
import java.util.LinkedList;
import java.util.Map;
import java.util.Set;
import java.util.zip.CRC32;
import java.util.zip.Checksum;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.obixlabs.commons.ObixException;
import com.obixlabs.commons.io.FileUtilities;
import com.obixlabs.commons.io.StreamReadWriteUtilities;


/**
 * <p>
 * Utility class for building ZIP archives. This class is relational, in that 
 * an instance can be used solely for the purpose of building a single
 * archive.
 * </p>
 */
public class ZIPBuilder
{
	/**
	 * <p>
	 * Standard file/folder path seperator as defined by the 
	 * ZIP standard.
	 * </p>
	 */
	public static final String ZIP_ENTRY_PATH_SEPERATOR = "/";

	/**
	 * <p>
	 * Static logger reference.
	 * </p>
	 */
	private static final Logger logger = 
				LoggerFactory.getLogger(ZIPBuilder.class);
	
	/**
	 * <p>
	 * Byte stream which serves the purpose of 
	 * holding the ZIP data which is being built by the
	 * instance. This serves as the underlying stream 
	 * for the {@link #zipStream ZIP stream} which performs
	 * the actual work of data compression.
	 * </p>
	 */
    private ByteArrayOutputStream byteStream;
    
    /**
     * <p>
     * The ZIP stream which performs the actual 
     * work of compressing {@link #add(String, byte[]) data 
     * added to the instance}.
     * </p>
     */
    private  ZipOutputStream zipStream;
    
    /**
     * <p>
     * Indicates if the instance should compress or
     * simply store the entries added to it.
     * </p>
     */
    private boolean compress;
    
    /**
     * <p>
     * This is only relevant when compressing 
     * folders. It indicates if the builder should
     * preserve the relative paths of directory contents.
     * </p>
     */
    private boolean preservePaths;
    
    /**
     * <p>
     * Checksum calculator which is used for 
     * error checking.
     * </p>
     */
    private Checksum checksum;
        
    
    /**
     * <p>
     * Constructs an instance using the default 
     * <code>CRC32</code> checksum. 
     * </p>
     * 
     * @param compress Indicates if the instance should compress or
     * simply store the data {@link #add(String, byte[]) added to it}.
     * 
     * @param preservePaths	Indicates if the builder should
     * preserve the relative paths of directory contents.
     * 
     * @see #preservePaths
     */
	public ZIPBuilder(boolean compress, boolean preservePaths)
	{ this(compress, preservePaths, new CRC32()); }
	
	
	/**
	 * <p>
	 * Identical in contract to {@link #ZIPBuilder(boolean, boolean)}
	 * except that it allows the caller to override use of the 
	 * <code>CRC32</code> checksum.
	 * </p>
	 * 
     * @param compress Indicates if the instance should compress or
     * simply store the data {@link #add(String, byte[]) added} to it.
     * 
     * @param preservePaths	Indicates if the builder should
     * preserve the relative paths of directory contents.

	 * @param checksum	The checksum used for error checking.
	 */
	public ZIPBuilder(	boolean compress, 
						boolean preservePaths, 
						Checksum checksum)
	{
		if (checksum==null)
			throw new IllegalArgumentException(
					"You must specify a Checksum. Suggest trying either CRC32 or Adler32.");		
		this.checksum = checksum;
		this.compress = compress;
		this.preservePaths = preservePaths;
		
		//initialise streams
		byteStream = new ByteArrayOutputStream();		
		zipStream = new ZipOutputStream(byteStream);
		if (this.compress)
			zipStream.setMethod(ZipEntry.DEFLATED);
		else zipStream.setMethod(ZipEntry.STORED);
	}
	
    
	/**
	 * <p>
	 * Adds the given file or directory to the ZIP archive.
	 * </p>
	 * 
	 * @param file	The file or directory to add to the archive.
	 * 
	 * @throws ObixException	Indicates an error reading the 
	 * contents of the given file or directory.
	 */
    public void add( File file) throws ObixException
	{
    	if (file.isDirectory())
    	{
    		if (preservePaths)
    			internalAddDirectoryPreservePaths(file);
    		else internalAddDirectoryWithoutPreserve(file);    		
    	}
    	else internalAddFile(file);
	}	
	
    /**
     * <p>
     * Adds the contents of the given stream to the 
     * ZIP archive. This method achieves the same thing 
     * as calling {@link #add(String, byte[])} with the 
     * bytes read from the given stream.
     * </p>
     * 
     * @param entryName	The name to assign to the 
     * ZIP entry which will be created from the bytes
     * read from the stream.
     * 
     * @param dataStream	The stream from which to 
     * read the bytes for the new entry.
     * 
	 * @throws ObixException	Indicates an error reading the 
	 * contents of the given stream.	
     */
    public void add(String entryName,
    				InputStream dataStream)
    				throws ObixException
    {
    	byte[] data = 
    		StreamReadWriteUtilities.
    			readStreamContentsAsBytes(dataStream);
    	add(entryName, data);    		
    }

    /**
     * <p>
     * Creates a ZIP entry corresponding to the given 
     * byte array and with the specified name.
     * </p>
     * 
     * @param entryName	The name to assign to the entry.
     * @param data	The data from which to create the entry.
     * 
	 * @throws ObixException Indicates an archive error.	
	 * 
	 * @see #add(String, byte[], long)
     */
    public void add(	String entryName,byte[] data) 
    					throws ObixException
    { add(entryName, data, System.currentTimeMillis()); }
    
    
    /**
     * <p>
     * Similar in contract to {@link #add(String, byte[])}, 
     * except that it allows the caller to specify the 
     * 'Last Modified Time' assigned to the resulting 
     * archive entry.
     * </p>
     * 
     * @param entryName	The name to assign to the entry.
     * @param data	The data from which to create an entry.
     * @param lastModifiedTime	The data-time (in milliseconds) 
     * to which the 'Last Modified Time' attribute of the 
     * resulting entry is set.
     * 
	 * @throws ObixException Indicates an archive error.	
     */
    public void add(	String entryName, 
    					byte[] data, 
    					long lastModifiedTime) 
						throws ObixException
	{ 
	    ZipEntry zipEntry = 
    		createZipEntry(entryName, data, 
    											lastModifiedTime);

	    try
	    {
	    	zipStream.putNextEntry(zipEntry);
	    	zipStream.write(data);
	    	zipStream.flush();	    	
	    	zipStream.closeEntry();        	
	    }
	    catch (IOException ioExce)
	    {
	    	throw new ObixException("Failed to add entry '" + entryName + ""+ 
	    							" due to an I/O error." , ioExce);
	    }    	
    }
    
    /**
     * <p>
     * Method which can be used to manually add/create a ZIP 
     * folder-entry from the given map of byte arrays. 
     * Each entry in the parameter map essentially becomes an 
     * element of the resulting directory.  
     * </p>
     * 
     * @param directoryName	The name to assign to the new directory.
     * If this parameter is null or empty, the resulting entries are 
     * placed in the root of the zip archive.
     *  
     * @param contents	The directory contents, keyed by their respective
     * names.
     * 
	 * @throws ObixException Indicates an archive error.
     */
	public void add(	String directoryName,
    					Map<String, byte[]> contents) 
    					throws ObixException
    { 
    	ZipEntry directoryEntry;
    	
    	
    	String directoryPrefix = directoryName!=null ? directoryName : "";
    	if (!directoryPrefix.endsWith(ZIP_ENTRY_PATH_SEPERATOR))
    		directoryPrefix += ZIP_ENTRY_PATH_SEPERATOR;
    	
    	directoryEntry = new ZipEntry(directoryPrefix);
    	directoryEntry.setTime(System.currentTimeMillis());
    	try
    	{
    		//create next entry
    		zipStream.putNextEntry(directoryEntry);
    		
    		//add directory contents
    		Set<String> entryNames = contents.keySet();
    		byte[] entryContents;
    		for (String entryName : entryNames)
			{
				entryContents = contents.get(entryName);
				add(directoryPrefix+entryName, 
							entryContents);
			}
    		
    		//close directory
    		zipStream.closeEntry();
    	}
        catch (IOException ioExce)
        {
        	throw new ObixException(	"Failed to add entry '" + directoryName + ""+ 
        																" due to an I/O error." , ioExce);
        }    	
    }
    
	/**
	 * <p>
	 * Finalizes the ZIP archive. No further 
	 * {@link #add(File) additions} should be made
	 * to the archive after this method is called.
	 * </p>
	 * 
	 * @throws ObixException Indicates an archive error.
	 * @see #closeQuietly()
	 */
    public void close() throws ObixException
    {
    	try
    	{
	        zipStream.finish();
	        zipStream.close();
    	}
        catch (IOException ioExce)
        { throw new ObixException(	"Failed to close ZIP builder.", ioExce);}    	
    }
    
    /**
     * <p>
     * Identical to {@link #close()} except that this method 
     * suppresses any errors that occur in finalizing the archive.
     * </p>
     */
    public void closeQuietly()
    {
    	try { close(); }
    	catch (ObixException exce) 
    	{ logger.warn("Error occured closing ZIPBuilder.", exce); }
    }
    
    /**
     * <p>
     * Returns the current approximate size of the 
     * ZIP archive. This method can be called at any point
     * in the lifecycle of the object.
     * </p>
     * 
     * @return	The current approximate size of the ZIP 
     * archive.
     */
    public int currentSize() {return byteStream.size();}
    
    /**
     * <p>
     * Returns the constructed ZIP archive as a byte array.
     * The method should only be called after the instance
     * is {@link #close() closed}. 
     * </p>
     * 
     * @return	The ZIP archive.
     */
    public byte[] toByteArray() { return byteStream.toByteArray();}
    
    /**
     * <p>
     * Indicates if compression is enabled.
     * </p>
     * 
     * @return <code>True</code> if compression is enabled and 
     * <code>false</code> otherwise.
     */
    public boolean isCompress(){return compress;}
    
    /**
     * <p>
     * Indicates if {@link #preservePaths path preservation} is 
     * enabled for directories.
     * </p>
     * @return <code>True</code> if path preservation is 
     * enabled and <code>false</code> otherwise.
     */
    public boolean isPreservePaths() {return preservePaths;}

    /**
     * <p>
     * Returns the checksum used by the instance for error
     * checking.
     * </p>
     * 
     * @return	 A reference to the instance's checksum.
     */
    public Checksum getChecksum() {return checksum;}
    
    /**
     * <p>
     * Writes the ZIP archive to the given file.
     * </p>
     * @param file	The file to write the ZIP archive to.
     * @throws ObixException	If an I/O error occurs.
     */
    public void write(File file) throws ObixException
    {FileUtilities.writeBytesToFile(file, toByteArray());}

    /**
     * <p>
     * Writes the ZIP archive to the given stream.
     * </p>
     * @param stream	The stream to write the ZIP archive to.
     * @throws ObixException	If an I/O error occurs.
     */
    public void write(OutputStream stream) throws ObixException
    {StreamReadWriteUtilities.writeBytesToStream(stream, toByteArray());}
    
    /**
     * <p>
     * Internal delegate method which adds a single
     * file to the archive.
     * </p>
     * 
     * @param file	The file to add to the archive.
     * @throws ObixException	Propagated from the 
     * {@link #add(String, byte[], long)} method.
     */
    private void internalAddFile(File file) throws ObixException
    {
    	byte[] data = FileUtilities.readFileContentsAsBytes(file);
    	add(file.getName(), data, file.lastModified());    	
    }
    
    /**
     * <p>
     * Adds the contents of the given directory to the 
     * archive in a flat structure i.e. without preserving
     * relative folder and file names. This method 
     * is invoked from from {@link #add(File)} when 
     * {@link #preservePaths path preservation is disabled}.
     * </p>
     * 
     * @param directory	The directory to add to the archive.
     * @throws ObixException	Masks an I/O or archive 
     * error.
     */
	private void internalAddDirectoryWithoutPreserve(
							File directory) throws ObixException
	{
		Deque<File> queue = new LinkedList<File>();
		queue.push(directory);

		File entry;
		File[] directoryContents;

		while (!queue.isEmpty())
		{
			entry = queue.pop();
			directoryContents = entry.listFiles();

			for (File child : directoryContents)
			{
				if (child.isDirectory()) queue.push(child);
				else internalAddFile(child);
			}
		}
	} 
	
    /**
     * <p>
     * Adds the contents of the given directory to the 
     * archive in a nested structure i.e. preserving
     * relative folder and file names. This method 
     * is invoked from from {@link #add(File)} when 
     * {@link #preservePaths path preservation is enabled}.
     * </p>
     * <p>
     * <b>Note:</b> This method is recursive.
     * </p>
     * 
     * @param directory	The directory to add to the archive.
     * @throws ObixException	Masks an I/O or archive 
     * error.
     */
	private void internalAddDirectoryPreservePaths(
									File directory) throws ObixException
	{
		URI base = directory.toURI();

		Deque<DirectoryProcessingQueueEntry> queue = 
								new LinkedList<DirectoryProcessingQueueEntry>();
		queue.push(new DirectoryProcessingQueueEntry(
													base.relativize(directory.toURI()), directory));

		DirectoryProcessingQueueEntry entry;
		ZipEntry zipEntry;
		String name;
		File[] directoryContents;

		try
		{
			while (!queue.isEmpty())
			{
				entry = queue.pop();
				directoryContents = entry.getDirectory().listFiles();

				name = entry.getRelativeURI().getPath();
				name = name.endsWith(
						ZIP_ENTRY_PATH_SEPERATOR) ? name : 
							name + ZIP_ENTRY_PATH_SEPERATOR;
				zipEntry = new ZipEntry(name);
				zipStream.putNextEntry(zipEntry);

				for (File child : directoryContents)
				{
					if (child.isDirectory())
						queue.push(new DirectoryProcessingQueueEntry(
															base.relativize(child.toURI()), child));
					else
						add(name + child.getName(),
								FileUtilities.readFileContentsAsBytes(child));
				}

				zipStream.closeEntry();
			}
		} catch (IOException exce) { throw ObixException.wrapException(exce); }
	} 
    
	/**
	 * <p>
	 * Internal delegate method which create a ZIP entry
	 * for the given data. 
	 * </p>
	 * @param entryName	The name to assign to the new entry.
	 * @param data	The data to be encapsulated by the entry and 
	 * from which a checksum will be calculated.
	 * @param lastModifiedTime	The 'Last Modified Time' to assign 
	 * to the new entry. 
	 * @return	A ZIP entry corresponding to the given parameters.
	 */
    private ZipEntry createZipEntry(	String entryName, 
    									byte[] data, 
    									long lastModifiedTime)
    {
        ZipEntry result = new ZipEntry(entryName);
        checksum.reset();
        checksum.update(data, 0, data.length);
        result.setCrc(checksum.getValue());
        result.setSize(data.length);
        
        if (compress)
        	result.setMethod(ZipEntry.DEFLATED);
        else
        {
            result.setMethod(ZipEntry.STORED);            
            result.setCompressedSize(data.length);
        }
        
        return result;
    }
    
    /**
     * <p>
     * Internal utility class which is used to queue nested 
     * directories for processing. 
     * </p>
     */
    private class DirectoryProcessingQueueEntry
    {
    	/**
    	 * <p>
    	 * The relative URI of the directory, 
    	 * in relation to the base directory being
    	 * added to the archive. 
    	 * </p>
    	 */
    	private URI relativeURI;
    	
    	/**
    	 * <p>
    	 * The directory's file handle.
    	 * </p>
    	 */
    	private File directory;
    	
    	/**
    	 * <p>
    	 * Constructs an instance using the relative 
    	 * URI and file handle.
    	 * </p>
    	 * @param relativeURI	The relative URI of the directory, 
    	 * in relation to the base directory being added to the archive.
    	 * @param directory	The directory's file handle.
    	 */
    	public DirectoryProcessingQueueEntry(	URI relativeURI, 
    											File directory)
		{
    		this.relativeURI = relativeURI;
    		this.directory = directory;
		}
    	
    	/**
    	 * <p>
    	 * Returns the relative URI of the directory, 
    	 * in relation to the base directory being
    	 * added to the archive. 
    	 * </p>
    	 * @return	The value of the {@link #relativeURI} property.
    	 */
    	public URI getRelativeURI() {return relativeURI;}
    	
    	/**
    	 * <p>
    	 * Returns the directory's file handle.
    	 * </p>
    	 * 
    	 * @return	A reference to the {@link #directory} property.
    	 */
    	public File getDirectory(){return directory;}
    }
}